Skip to content

Make substrate's namespace, Service names and ServiceAccount names configurable - #31

Open
jjamroga wants to merge 12 commits into
mainfrom
jjamroga/namespace-agnostic-identities
Open

Make substrate's namespace, Service names and ServiceAccount names configurable#31
jjamroga wants to merge 12 commits into
mainfrom
jjamroga/namespace-agnostic-identities

Conversation

@jjamroga

@jjamroga jjamroga commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What

Substrate assumed it was installed as the canonical release: namespace
ate-system, Services named api / atenet-router / dns, and
ServiceAccounts named atelet / atenet-router. All of those were
compiled-in constants, so an install anywhere else failed — and mostly failed
closed, in ways that do not point at naming.

This makes the namespace, the Service names and the ServiceAccount names
configuration, and wires the chart to pass what it actually rendered.
Everything defaults to the canonical values, so an existing install is
unaffected.

The Go and script changes here are the same ones proposed upstream in
agent-substrate#350. The chart changes are fork-only, since the chart
lives here. Once the upstream PR lands, this branch rebases onto it and only
the chart commits remain.

Why this matters here

The chart prefixes every resource name for a release not called substrate.
That is exactly what consuming substrate as a subchart produces, since a
subchart inherits the parent's release name — so the ServiceAccounts render as
<release>-atelet and <release>-atenet-router. A SPIFFE ID names a
ServiceAccount as well as a namespace, so identities built from the constants
named accounts that did not exist and every peer was rejected.

Concretely, before this change a worker in a subchart install was told to
expect spiffe://cluster.local/ns/ate-system/sa/atelet while atelet actually
presented spiffe://cluster.local/ns/<release-ns>/sa/<release>-atelet. No
actor could obtain a credential.

What was broken

Assumption How it failed
atenet-dns's peer Services DNS never resolved actors
atelet's identity in ateapi mTLS handshake failed — not a lookup error
atelet's identity in atunnel credential broker is not atelet; no actor got a certificate
NetworkPolicy's ingress namespace CNI dropped every request to the pool, silently
ate-controller's POD_NAMESPACE not projected, so it stamped workers with the wrong namespace
atelet's --ateapi-address not passed by the chart; endpointslices is forbidden
ateapi Service name in the client services "api" not found, then invalid bearer token
Generated Role's namespace landed outside the release namespace; ate-controller lost a permission
Resource names in install script and e2e harness authorities in the wrong namespace; suites died in preflight

Notes for review

  • hack/gen-rbac.sh templatizes the Role's namespace. A kubebuilder rbac
    marker has to name a literal namespace, so controller-gen emits the canonical
    one; the script already rewrote the Role's name and now rewrites its
    namespace the same way. manifests/ate-install/ is unchanged, because it
    renders with --namespace ate-system and the templated value produces the
    same bytes.
  • ate-controller was the only POD_NAMESPACE consumer whose template did not
    project it.
    Five other templates already did, and the rendered manifest has
    carried it all along, so the chart had drifted from its own output.
  • The client reads environment variables, not flags. It runs outside the
    cluster, so it has no downward API and nothing to discover from.
  • The install script and e2e harness are included deliberately. A relocated
    or renamed install cannot be bootstrapped or exercised without them.

Testing

Verified on kind in three topologies.

Relocated namespace — installed into a namespace other than ate-system:
control plane healthy, kubectl-ate authenticating, actor template reaching
Ready, substrate e2e suites passing.

Renamed release — release name other than substrate, so every resource is
prefixed: same results, with the identities correct on both the namespace and
the ServiceAccount.

As a subchart of kagenthelm install kagent helm/kagent --set substrate.enabled=true, substrate pulled as an OCI dependency. Everything came
up in one namespace under prefixed names, kagent's WorkerPool was reconciled by
substrate's controller, and the resulting worker carried

--atunnel-client-identity=spiffe://cluster.local/ns/kagent/sa/kagent-atenet-router
--atunnel-broker-identity=spiffe://cluster.local/ns/kagent/sa/kagent-atelet

kagent's own e2e suite passed against that install, covering agent interaction,
resume, persistence and checkpointing. The one failure was a foreign-key
violation in kagent's own database, in a path substrate is not part of.

Unit tests cover the relocated and renamed cases specifically. The pre-existing
tests all passed canonical values, so they would have held against the
hardcoding they are meant to catch; the new ones were checked by reintroducing
the hardcoding and confirming they fail.

Follow-up

kagent needs one change of its own to consume this as a subchart: its
controller.substrate.ateApiEndpoint defaults to api.ate-system.svc, which is
right for installing substrate as a separate release but wrong when the Service
is <release>-api. This chart already exposes substrate.ateApi.endpoint and
substrate.atenetRouter.url for parent charts to use.

Worth deciding separately: whether kagent should ship with the substrate
subchart enabled. It is gated in two charts today — kagent-crds and kagent,
both defaulting to false — so enabling only one installs no CRDs and silently
does nothing.

EItanya and others added 12 commits September 3, 2026 11:53
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
The chart already renders every resource into the release namespace and
passes the resolved Service names to atenet-dns, but several identity
checks still assume substrate occupies "ate-system". Each one fails
closed in a relocated install, and none of them fails in a way that
points at the namespace:

  - atunnel verifies atelet's SPIFFE ID on the credential broker
    connection against a hardcoded ID, so no actor can obtain a
    certificate. atunnel runs in the actor's pod, whose POD_NAMESPACE is
    the actor's and not atelet's, so the expected identity is passed in.
  - The NetworkPolicy the WorkerPool controller generates admits ingress
    only from "ate-system". Pointed at the wrong namespace it admits
    nobody, and the CNI drops every request to the pool's actors with no
    error from substrate itself.
  - actoridentity rejects atelet's MintCert/MintJWT calls as "not
    atelet". It re-declared the atelet SPIFFE segments to avoid depending
    on controlapi, with a comment asking for a shared package once a
    third consumer appeared; atunnel is that third consumer, so the
    segments move to installdefaults.
  - The egress MITM CA pool Secret was looked up in a hardcoded
    namespace.

atunnel already took the atenet-router identity as a flag, so the atelet
identity follows that convention. Both flags previously relied on their
defaults being right; ate-controller now passes them explicitly, since it
is the component that knows which namespace the control plane occupies.
Without that the new flag would be inert — correct only where the old
hardcoded value was already correct.

hack/gen-rbac.sh now templatizes the namespace of the generated
ate-controller Role alongside its name. A kubebuilder rbac marker has to
name a literal namespace, so controller-gen emits "ate-system"; left
alone, the Role lands outside the release namespace and ate-controller
loses its endpointslices permission. manifests/ate-install/ is unchanged:
it renders with --namespace ate-system, so the templated value produces
the same bytes.

Renaming the release is a separate axis and still unsupported: the chart
prefixes ServiceAccount names, which SPIFFE IDs embed, and those names
remain constants in installdefaults.
The chart installs into whatever namespace the release targets, but the
tooling around it did not: hack/install-ate.sh hardcoded "ate-system" in
52 places and internal/e2e in 9, so a relocated install could not be
bootstrapped or exercised. The mTLS authorities landed in ate-system
while the control plane looked for them in the release namespace, and
the e2e suites addressed the router and egress CA pool in a namespace
they were no longer in.

install-ate.sh now reads ATE_NAMESPACE and the e2e harness reads
E2E_SYSTEM_NAMESPACE, both defaulting to ate-system so existing flows and
CI are unaffected.

The one part that is not a substitution is namespace creation.
manifests/ate-install/ate-system-namespace.yaml names ate-system and
carries labels the chart also sets, so it stays the source of truth for
the canonical install; ensure_ate_namespace falls back to a plain create
for any other namespace.
Two gaps that only appear once substrate is installed outside ate-system,
both found by running the helm-e2e flow against a relocated release.

ate-controller reads the namespace from the downward API to build the
atelet and atenet-router SPIFFE identities it hands to each worker's
atunnel, but its template projected no POD_NAMESPACE. It fell back to the
compiled-in default and stamped every worker with
spiffe://cluster.local/ns/ate-system/sa/atelet, so atunnel rejected the
credential broker with "credential broker is not atelet" and no actor
could obtain a certificate. Five other templates already project the
variable; ate-controller was the only consumer that did not, and the
rendered manifest under manifests/ate-install/ has carried it all along.

atelet's credential broker dials ateapi to mint those certificates. The
chart passes a resolved --ateapi-address to ate-controller, atenet-router
and atenet-egress, but not to atelet, which fell back to
k8s:///api.ate-system.svc:443 and failed with "k8sresolver: watch error
for ate-system/api ... endpointslices is forbidden" — the RBAC it has
covers the release namespace, not the one it was dialing.
--ateapi-server-name has the same default and the same problem, since it
is the name checked on ateapi's serving cert.
The probe hardcoded atenet-egress.ate-system.svc:443 as its
--gateway-address default and nothing overrode it, so against a relocated
install every egressauthz case failed in the tunnel stage with "no such
host" — before reaching the ext_proc identity check the tests exist to
cover, which reported as an authorization failure rather than a lookup
one.

The probe runs inside the sandbox and cannot infer substrate's namespace,
so the suite substitutes it into the manifest the same way it already
substitutes the probe's own namespace.
Two pieces of the upstream branch this fork's version had not picked up.

The atelet dialer resolved the namespace from POD_NAMESPACE at the point
of use, independently of the informer that was already given one. Both
read the same variable so the behaviour matches, but the namespace is now
threaded in alongside the informer's, which keeps a single resolution
point and matches upstream so the eventual rebase is a no-op here. The
trust-domain and service-account segments move to installdefaults with
it.

atenet-router's /statusz looked up its own ClusterIP under the hardcoded
Service name atenet-router, which is wrong for any release this chart
prefixes. It takes --router-service-name, and the chart passes the name
it actually rendered.
A SPIFFE ID names a ServiceAccount as well as a namespace, and the
ServiceAccount name was still a constant. This chart prefixes every
resource name for a release not called "substrate" — which is every
install that consumes substrate as a subchart, since a subchart inherits
the parent's release name — so ateapi and each worker's atunnel expected
an account that does not exist and rejected the peer that does.

ateapi takes --atelet-service-account and ate-controller takes that plus
--router-service-account, both defaulting to the canonical names, and the
chart passes the names it actually rendered.

ateapi's dialer and actoridentity now take the whole expected identity
rather than a namespace they rebuild it from. Both only ever used the
namespace to construct that one string, and having a single place decide
what atelet's identity is beats two places agreeing on how to spell it.
Installing substrate as a subchart renames more than the ServiceAccounts.
The Service fronting ateapi becomes <release>-api and the ServiceAccount
the CLI mints its token from becomes <release>-ate-client, and both were
constants: kubectl-ate port-forwarded to a Service that does not exist,
and once that was fixed it minted a token for the wrong audience and
ateapi rejected it as an invalid bearer token.

The client reads ATE_API_SERVICE_NAME and ATE_CLIENT_SERVICE_ACCOUNT
alongside the ATE_NAMESPACE it already read; it runs outside the cluster
and cannot discover any of this. hack/install-ate.sh reads the same
ATE_API_SERVICE_NAME, since the audience it writes into the API
authentication config has to name the same Service the client dials.

All three default to the canonical names.
The harness looked up ate-controller, ate-api-server, atenet-router and
the egress CA pool Secret by their canonical names. A release the chart
prefixes — which is any subchart install, since a subchart inherits the
parent's release name — renders them as <release>-ate-controller and so
on, and every suite failed in preflight before running a single case.

E2E_RESOURCE_PREFIX carries what the install under test prepends, empty
by default.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants